Fix: fence register/unregister and make shutdown sticky - #1663
Conversation
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe mailbox protocol now uses a sticky shutdown marker separate from mailbox state. Worker loops observe the marker during control processing. Registration and unregistration operations retain leases across publication and cleanup transactions. Tests cover admission fencing and shutdown races. ChangesWorker lifecycle and mailbox coordination
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant ParentWorker
participant LocalMailboxEndpoint
participant ChildMailboxLoop
participant ControlCommand
ParentWorker->>LocalMailboxEndpoint: request shutdown
LocalMailboxEndpoint->>ChildMailboxLoop: publish sticky shutdown marker
LocalMailboxEndpoint->>ChildMailboxLoop: publish SHUTDOWN state
ChildMailboxLoop->>ControlCommand: finish in-flight control command
ChildMailboxLoop->>ChildMailboxLoop: recheck sticky shutdown marker
ChildMailboxLoop-->>ParentWorker: terminate successfully
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@python/simpler/worker.py`:
- Around line 5225-5241: Update _post_start_register_l2 so failures from
_chip_worker._register_callable_at_slot roll back the newly installed
registration, mirroring _post_start_register_chip. Only remove the registration
when is_new is true and prewarming raises, then propagate the original exception
while preserving successful registration behavior.
In `@tests/ut/py/test_worker/test_admission_fence.py`:
- Around line 63-66: Update the exception suppression in the worker cleanup
try/except around w.close() to also silence Ruff’s S110 warning, while retaining
the existing BLE001 suppression and its explanatory reason. Keep the intentional
swallowing of close() errors unchanged because the worker may already be closed.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 8c265d6d-5916-4d8f-9d34-ac8888cc9a90
📒 Files selected for processing (6)
docs/worker-manager.mdpython/simpler/worker.pysrc/common/hierarchical/worker_manager.cppsrc/common/hierarchical/worker_manager.htests/ut/py/test_worker/test_admission_fence.pytests/ut/py/test_worker/test_host_buffer_registration.py
06c9ba6 to
eba8aac
Compare
close() publishes CLOSED and then drains _active_ops before it touches the tree, so a registry mutation made outside an operation lease is invisible to that drain. register() published the handle before taking its lease on the L3+ post-init ChipCallable path and on the L2 pre-warm path, and unregister() held no lease at all, so a close() could pass admission mid-transaction and tear the tree down underneath it. - register(): the post-READY L3+ and L2 paths take the lease before publication, so publication and the child-facing call are one transaction. Pre-start (NEW) and INITIALIZING registrations keep the epoch linearization they had, since the lease admits only READY. - unregister(): the live-target paths (L3+ child broadcast, L2 device slot release) take a symmetric lease. Whether the target is reachable is decided once by the caller under that lease instead of being re-read from the lifecycle across the broadcast. A registry-only decrement keeps the no-lease path so it still works before init(). - The rollback on a failed broadcast stays. The lease covers a racing close(); it says nothing about a broadcast that fails on its own. - _post_start_register_l2 rolls back on a failed pre-warm, matching _post_start_register_chip: if _register_callable_at_slot raises, the caller never receives the handle, so the published cid leaks against MAX_REGISTERED_CALLABLE_IDS otherwise. Termination had the mirror-image problem on the wire. The mailbox state word has three writers, so the CONTROL_DONE a child publishes for an in-flight control command overwrites a concurrent SHUTDOWN store; the child then polls a mailbox whose request has been erased until the parent's reap deadline expires and reports it as a survivor. MAILBOX_OFF_SHUTDOWN is a sticky word written only by a terminating parent, 0 -> 1, never cleared, and both serve loops read it at the top of every iteration alongside the state word. It is reserved on every frame by shrinking MAILBOX_ARGS_CAPACITY by 8 bytes, so no existing offset moves and run_control_command is unchanged. tests/ut/py/test_worker/test_admission_fence.py covers all three: the lease is held at publication and at the unregister broadcast, close() drains a transaction in flight, and a shutdown that races an in-flight control command still ends the child. Four of the five fail against the previous code. The host-buffer registration test scripted the serve loop's poll sequence, so its fake now answers the shutdown address.
Summary
Two coupled admission defects: a window on the host side, and the consequence it produces on the wire.
register/unregistermutate the registry outside the operation lease.close()publishes CLOSED and then drains_active_opsbefore it touches the tree, so any registry mutation made outside a lease is invisible to that drain.register()published the handle before taking its lease on the L3+ post-initChipCallablepath and on the L2 pre-warm path.unregister()held no lease at all — it popped_live_handles, decrementedref_count, set the tombstone, and then broadcast bare.A
close()could therefore pass admission mid-transaction and start tearing the tree down underneath either one. The existing_rollback_handle_lockeddid not cover it: it fires only when the broadcast itself raises, and aclose()winning the race does not make the broadcast raise.SHUTDOWNis overwritable. The mailbox state word has three writers — the parent'sCONTROL_REQUEST, the child'sCONTROL_DONE, andrun_control_command's return toIDLE. TheCONTROL_DONEa child publishes for an in-flight control command lands after a concurrentSHUTDOWNstore and erases it, so the child polls a mailbox whose request is gone until the parent's reap deadline expires and reports it as a survivor. The C++ side already worked around this by repeating the store (poisoned_progress_quiesced), which narrows the window rather than closing it.Changes
register(): post-READY L3+ and L2 paths take_operation_lease("register")before publication, so publication and the child-facing call are one transaction. The three lease sites (remote / python / chip) now enter at the same point. Pre-start (NEW) and INITIALIZING registrations are untouched — they keep_register_into_snapshot_or_wait, since the lease admits only READY and would break pre-start registration.unregister(): the live-target paths (L3+ child broadcast, L2 device-slot release) take a symmetric lease. Whether the target is reachable is now decided once by the caller under that lease instead of being re-read from_initialized/_hierarchical_startedacross the broadcast. A registry-only decrement keeps the no-lease path so it still works beforeinit().ChipWorker, and pre-fix it had the same publish-then-lease window with no rollback — aclose()landing there left the entry published on a CLOSED worker. It is now fenced too. Its failure semantics are unchanged; no rollback was added.MAILBOX_OFF_SHUTDOWN: a sticky one-way word written only by a terminating parent,0 -> 1, never cleared. Both terminating writers set it — Python_request_child_shutdown(now the sole writer of the request, used by_broadcast_child_shutdownand the startup-rollback graceful phase) and C++LocalMailboxEndpoint::shutdown_child— sticky word first, then the state word. Both serve loops (_run_mailbox_loop, the chip two-frame loop) read it at the top of every iteration alongside the state word.Wire delta
One new
int32atMAILBOX_OFF_SHUTDOWN = MAILBOX_OFF_FRAME_PROTOCOL - 8, reserved on every frame by shrinkingMAILBOX_ARGS_CAPACITYby 8 bytes (64024 -> 64016 on the post-#1648 64 KiB frame; the max-blobstatic_asserthas ~30 KiB of slack). No existing offset moves, andrun_control_command'sIDLE/CONTROL_REQUESTwrites need no change — the new word is not in their write set, which is why this is preferable to converting the state word to CAS.No path reuses a mailbox after a shutdown request: mailbox shms are created fresh per child, and every
shutdown_child()caller (stop_workers,fail_progress_driver,poisoned_progress_quiesced) is terminal.Testing
New
tests/ut/py/test_worker/test_admission_fence.py— 5 device-free tests (L3 worker, one SUB child, no chips), each with its own hard timeout. 4 of the 5 fail against the previous code.test_publication_holds_the_lease— register publishes under a lease (red before)test_close_drains_a_register_in_flight— close() cannot tear down mid-broadcasttest_broadcast_holds_the_lease— unregister broadcasts under a lease (red before)test_close_cannot_slip_into_the_unregister_broadcast— close() returned in 3.7 ms before; now fenced (red before)test_shutdown_survives_an_in_flight_control_command— forks a real_run_mailbox_loop, holds its control handler open, requests shutdown underneath it, releases; the child must exit. Asserts the state word really did end upCONTROL_DONE, so it cannot pass by missing the race (red before — the child hung to the test budget)Test 2 is the one that passes both ways, and deliberately so: a park at publication is held under
_registry_lock, whichclose()'s registry detach also takes, so it would delayclose()even with no lease at all and cannot discriminate. It is retargeted at the broadcast and kept as the symmetric partner of test 4; test 1 is the actual register-side barrier.test_host_buffer_registration.pyscripts the serve loop's poll sequence through a monkeypatched_mailbox_load_i32; the loop now reads two words per iteration, so its fake answers the shutdown address separately.tests/ut/py— 1057 passed, 13 skippedctest -LE requires_hardware)-m "not sdma", viatask-submit) exit 0, 62 groups PASS / 0 fail, L2 host_build_graph and L2 tensormap_and_ringbuffer both PASS; quarantined-m sdmaset exit 0pre-commitclean on the changed filesAll of the above re-run after rebasing onto
upstream/main(#1648 resized the mailbox frame 32 KiB -> 64 KiB, which these offsets derive from).Notes
This lands the first two exit conditions of the P0.2 lifecycle-hardening follow-up (symmetric
register/unregisterfencing, and aSHUTDOWNthat cannot be overwritten). Not in scope: the eligibility revalidation, the_CloseAttempt/ cancellation cluster, and the retryable commit-barrier journal.The lost-shutdown mechanism is a candidate, unproven cause of #1562; this PR is not claiming to fix it, and its acceptance is the regression tests above.